-
Notifications
You must be signed in to change notification settings - Fork 4
feat: approvals page #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: approvals page #310
Conversation
WalkthroughA new dedicated approvals dashboard page is introduced to handle device approval/rejection workflows. The main dashboard is simplified by removing its inline pending approval panel. The layout header gains a notification badge displaying pending approvals count, linking to the new approvals page. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant ApprovalsPage
participant QueryClient
participant useGetDevices
participant useApproveDevice
participant useRevokeDevice
participant API
User->>ApprovalsPage: Visit /approvals
ApprovalsPage->>useGetDevices: Fetch unapproved devices (refetch: 5s)
useGetDevices->>API: GET /devices?approved=false
API-->>useGetDevices: Return device list
useGetDevices-->>ApprovalsPage: Display devices in list
User->>ApprovalsPage: Click Approve button
ApprovalsPage->>ApprovalsPage: Set processing indicator
ApprovalsPage->>useApproveDevice: Trigger approval
useApproveDevice->>API: POST /devices/{id}/approve
API-->>useApproveDevice: Success response
useApproveDevice-->>ApprovalsPage: Approval complete
ApprovalsPage->>QueryClient: Invalidate unapproved devices query
QueryClient->>useGetDevices: Refetch devices
useGetDevices->>API: GET /devices?approved=false
API-->>useGetDevices: Updated device list
ApprovalsPage->>ApprovalsPage: Show success toast (3s auto-dismiss)
User->>ApprovalsPage: Click Reject button
ApprovalsPage->>ApprovalsPage: Show confirmation prompt
User->>ApprovalsPage: Confirm rejection
ApprovalsPage->>ApprovalsPage: Set processing indicator
ApprovalsPage->>useRevokeDevice: Trigger rejection
useRevokeDevice->>API: POST /devices/{id}/revoke
API-->>useRevokeDevice: Success response
useRevokeDevice-->>ApprovalsPage: Rejection complete
ApprovalsPage->>QueryClient: Invalidate unapproved devices query
QueryClient->>useGetDevices: Refetch devices
useGetDevices->>API: GET /devices?approved=false
API-->>useGetDevices: Updated device list
ApprovalsPage->>ApprovalsPage: Show success toast (3s auto-dismiss)
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@dashboard/app/`(private)/approvals/page.tsx:
- Around line 63-91: The handleApprove function currently calls
approveDeviceHook.mutateAsync without error handling; wrap the mutateAsync call
in a try/catch/finally so network/server errors don’t cause unhandled promise
rejections and so processing state is always cleaned up. Specifically, in
handleApprove: add try { const success = await approveDeviceHook.mutateAsync({
deviceId }); ... } catch (err) { setToast({ message: `Failed to approve
${deviceName}: ${err?.message || "unknown error"}`, type: "error" }); /*
optionally log error */ } finally { remove deviceId from processingDevices (use
the existing setProcessingDevices logic) } and ensure
queryClient.invalidateQueries and the success toast remain inside the try when
success is truthy.
- Around line 93-129: The handleReject function must mirror handleApprove's
error handling: wrap the async revokeDeviceHook.mutateAsync call and subsequent
logic in try/catch/finally so errors are caught and a failure toast is shown in
the catch, while queryClient.invalidateQueries({ queryKey:
unapprovedDevices.queryKey }) and the success toast remain inside the try when
mutateAsync returns success; always remove the deviceId from
setProcessingDevices in finally to avoid stuck processing state. Reference:
handleReject, revokeDeviceHook.mutateAsync, setProcessingDevices,
queryClient.invalidateQueries, unapprovedDevices.queryKey.
🧹 Nitpick comments (1)
dashboard/app/(private)/approvals/page.tsx (1)
46-57: Duplicate helper function — consider extracting to a shared utility.
formatTimeAgois duplicated fromdashboard/page.tsx(lines 60-71). Extract this to a shared utility file (e.g.,@/app/utils/date.ts) to avoid drift and reduce maintenance burden.
| const handleApprove = async (deviceId: number, e: React.MouseEvent) => { | ||
| e.stopPropagation(); | ||
|
|
||
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | ||
| const deviceName = device?.serial_number || "Device"; | ||
|
|
||
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | ||
|
|
||
| const success = await approveDeviceHook.mutateAsync({ deviceId }); | ||
|
|
||
| if (success) { | ||
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | ||
| setToast({ | ||
| message: `${deviceName} approved successfully`, | ||
| type: "success", | ||
| }); | ||
| } else { | ||
| setToast({ | ||
| message: `Failed to approve ${deviceName}`, | ||
| type: "error", | ||
| }); | ||
| } | ||
|
|
||
| setProcessingDevices((prev) => { | ||
| const newSet = new Set(prev); | ||
| newSet.delete(deviceId); | ||
| return newSet; | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling around mutateAsync — unhandled promise rejection on network/server errors.
mutateAsync throws when the mutation fails (network error, server error, etc.). Without a try/catch, this will result in an unhandled promise rejection, leaving processingDevices in an inconsistent state and potentially crashing the component.
🐛 Proposed fix: wrap in try/catch
const handleApprove = async (deviceId: number, e: React.MouseEvent) => {
e.stopPropagation();
const device = unapprovedDevices.data?.find((d) => d.id === deviceId);
const deviceName = device?.serial_number || "Device";
setProcessingDevices((prev) => new Set(prev).add(deviceId));
+ try {
const success = await approveDeviceHook.mutateAsync({ deviceId });
if (success) {
queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey });
setToast({
message: `${deviceName} approved successfully`,
type: "success",
});
} else {
setToast({
message: `Failed to approve ${deviceName}`,
type: "error",
});
}
+ } catch (error) {
+ setToast({
+ message: `Failed to approve ${deviceName}`,
+ type: "error",
+ });
+ } finally {
+ setProcessingDevices((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(deviceId);
+ return newSet;
+ });
+ }
-
- setProcessingDevices((prev) => {
- const newSet = new Set(prev);
- newSet.delete(deviceId);
- return newSet;
- });
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleApprove = async (deviceId: number, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | |
| const deviceName = device?.serial_number || "Device"; | |
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | |
| const success = await approveDeviceHook.mutateAsync({ deviceId }); | |
| if (success) { | |
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | |
| setToast({ | |
| message: `${deviceName} approved successfully`, | |
| type: "success", | |
| }); | |
| } else { | |
| setToast({ | |
| message: `Failed to approve ${deviceName}`, | |
| type: "error", | |
| }); | |
| } | |
| setProcessingDevices((prev) => { | |
| const newSet = new Set(prev); | |
| newSet.delete(deviceId); | |
| return newSet; | |
| }); | |
| }; | |
| const handleApprove = async (deviceId: number, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | |
| const deviceName = device?.serial_number || "Device"; | |
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | |
| try { | |
| const success = await approveDeviceHook.mutateAsync({ deviceId }); | |
| if (success) { | |
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | |
| setToast({ | |
| message: `${deviceName} approved successfully`, | |
| type: "success", | |
| }); | |
| } else { | |
| setToast({ | |
| message: `Failed to approve ${deviceName}`, | |
| type: "error", | |
| }); | |
| } | |
| } catch (error) { | |
| setToast({ | |
| message: `Failed to approve ${deviceName}`, | |
| type: "error", | |
| }); | |
| } finally { | |
| setProcessingDevices((prev) => { | |
| const newSet = new Set(prev); | |
| newSet.delete(deviceId); | |
| return newSet; | |
| }); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@dashboard/app/`(private)/approvals/page.tsx around lines 63 - 91, The
handleApprove function currently calls approveDeviceHook.mutateAsync without
error handling; wrap the mutateAsync call in a try/catch/finally so
network/server errors don’t cause unhandled promise rejections and so processing
state is always cleaned up. Specifically, in handleApprove: add try { const
success = await approveDeviceHook.mutateAsync({ deviceId }); ... } catch (err) {
setToast({ message: `Failed to approve ${deviceName}: ${err?.message || "unknown
error"}`, type: "error" }); /* optionally log error */ } finally { remove
deviceId from processingDevices (use the existing setProcessingDevices logic) }
and ensure queryClient.invalidateQueries and the success toast remain inside the
try when success is truthy.
| const handleReject = async (deviceId: number, e: React.MouseEvent) => { | ||
| e.stopPropagation(); | ||
|
|
||
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | ||
| const deviceName = device?.serial_number || "Device"; | ||
|
|
||
| if ( | ||
| !confirm( | ||
| `Are you sure you want to reject ${deviceName}? This will archive it.`, | ||
| ) | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | ||
|
|
||
| const success = await revokeDeviceHook.mutateAsync({ deviceId }); | ||
|
|
||
| if (success) { | ||
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | ||
| setToast({ | ||
| message: `${deviceName} rejected and archived`, | ||
| type: "success", | ||
| }); | ||
| } else { | ||
| setToast({ | ||
| message: `Failed to reject ${deviceName}`, | ||
| type: "error", | ||
| }); | ||
| } | ||
|
|
||
| setProcessingDevices((prev) => { | ||
| const newSet = new Set(prev); | ||
| newSet.delete(deviceId); | ||
| return newSet; | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same error handling issue as handleApprove — wrap in try/catch/finally.
Apply the same fix pattern here. Additionally, consider replacing the native confirm() with a custom modal component for consistency with the app's design system, though this is a lower priority.
🐛 Proposed fix
const handleReject = async (deviceId: number, e: React.MouseEvent) => {
e.stopPropagation();
const device = unapprovedDevices.data?.find((d) => d.id === deviceId);
const deviceName = device?.serial_number || "Device";
if (
!confirm(
`Are you sure you want to reject ${deviceName}? This will archive it.`,
)
) {
return;
}
setProcessingDevices((prev) => new Set(prev).add(deviceId));
+ try {
const success = await revokeDeviceHook.mutateAsync({ deviceId });
if (success) {
queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey });
setToast({
message: `${deviceName} rejected and archived`,
type: "success",
});
} else {
setToast({
message: `Failed to reject ${deviceName}`,
type: "error",
});
}
+ } catch (error) {
+ setToast({
+ message: `Failed to reject ${deviceName}`,
+ type: "error",
+ });
+ } finally {
+ setProcessingDevices((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(deviceId);
+ return newSet;
+ });
+ }
-
- setProcessingDevices((prev) => {
- const newSet = new Set(prev);
- newSet.delete(deviceId);
- return newSet;
- });
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleReject = async (deviceId: number, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | |
| const deviceName = device?.serial_number || "Device"; | |
| if ( | |
| !confirm( | |
| `Are you sure you want to reject ${deviceName}? This will archive it.`, | |
| ) | |
| ) { | |
| return; | |
| } | |
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | |
| const success = await revokeDeviceHook.mutateAsync({ deviceId }); | |
| if (success) { | |
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | |
| setToast({ | |
| message: `${deviceName} rejected and archived`, | |
| type: "success", | |
| }); | |
| } else { | |
| setToast({ | |
| message: `Failed to reject ${deviceName}`, | |
| type: "error", | |
| }); | |
| } | |
| setProcessingDevices((prev) => { | |
| const newSet = new Set(prev); | |
| newSet.delete(deviceId); | |
| return newSet; | |
| }); | |
| }; | |
| const handleReject = async (deviceId: number, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const device = unapprovedDevices.data?.find((d) => d.id === deviceId); | |
| const deviceName = device?.serial_number || "Device"; | |
| if ( | |
| !confirm( | |
| `Are you sure you want to reject ${deviceName}? This will archive it.`, | |
| ) | |
| ) { | |
| return; | |
| } | |
| setProcessingDevices((prev) => new Set(prev).add(deviceId)); | |
| try { | |
| const success = await revokeDeviceHook.mutateAsync({ deviceId }); | |
| if (success) { | |
| queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }); | |
| setToast({ | |
| message: `${deviceName} rejected and archived`, | |
| type: "success", | |
| }); | |
| } else { | |
| setToast({ | |
| message: `Failed to reject ${deviceName}`, | |
| type: "error", | |
| }); | |
| } | |
| } catch (error) { | |
| setToast({ | |
| message: `Failed to reject ${deviceName}`, | |
| type: "error", | |
| }); | |
| } finally { | |
| setProcessingDevices((prev) => { | |
| const newSet = new Set(prev); | |
| newSet.delete(deviceId); | |
| return newSet; | |
| }); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@dashboard/app/`(private)/approvals/page.tsx around lines 93 - 129, The
handleReject function must mirror handleApprove's error handling: wrap the async
revokeDeviceHook.mutateAsync call and subsequent logic in try/catch/finally so
errors are caught and a failure toast is shown in the catch, while
queryClient.invalidateQueries({ queryKey: unapprovedDevices.queryKey }) and the
success toast remain inside the try when mutateAsync returns success; always
remove the deviceId from setProcessingDevices in finally to avoid stuck
processing state. Reference: handleReject, revokeDeviceHook.mutateAsync,
setProcessingDevices, queryClient.invalidateQueries, unapprovedDevices.queryKey.
No description provided.